Skip to content

fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884) - #680

Merged
artokun merged 17 commits into
mainfrom
fix/884-orchestrator-scoped-sessions
Aug 15, 2026
Merged

fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884)#680
artokun merged 17 commits into
mainfrom
fix/884-orchestrator-scoped-sessions

Conversation

@artokun

@artokun artokun commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Panel-side companion to artokun/comfyui-mcp#897 (MERGED 2026-08-06), which made agent sessions orchestrator-scoped — one session per backend across every panel, tab and workflow, keyed and persisted by the orchestrator. That half landed; this is the half that was still missing. Until it lands, main ships a panel that can still put the user in a per-workflow conversation while the orchestrator runs a single global session, so the transcripts silently diverge from the agent's real context.

What this changes

1. The workflow/ask chat scopes are retired. chatScopeMode() is hard-wired to "panel". The Settings → "Chat conversation scope" combo, panelHooks.applyChatScope, askModeFollowsPanel and the "ask" window.confirm are all removed — there is no live scope switcher left to reintroduce per-workflow sessions behind the orchestrator's back. A stored workflow/ask value from an older build is ignored, not migrated. Per-workflow threads created under the old modes stay in history and open through the picker like any archived conversation; nothing is deleted.

historyScopeFollowsPanel() is kept as a named seam (now constant) so the remaining per-workflow guards stay honest defense-in-depth rather than silently deleted invariants. That is a reasonable choice and also a standing hazard — the whole retired path wakes up the moment chatScopeMode() stops being a constant — so it is now pinned by gates rather than left to inspection (below).

2. Which conversation a tab renders is SHARED state, not tab-local. One selector — selectPanelThread — resolves it on cold restore and on cross-tab sync alike. A tab either acts (loadThread / newChat: dispatch the session frame, then publish the pointer) or adopts (reads the pointer and repaints). Adoption is passive, so N tabs never race to reset the one global session.

3. The selection pointer is BACKEND-scoped (panel:backend:<id>), the same axis as the orchestrator's orchestrator::<backend> session key — a Claude tab's selection must not move a Codex tab's conversation. resolvePanelPointer keeps a one-way legacy fallback to panel:global: once a backend key is written (including a deliberate clear) it never falls back again.

4. Stale-pointer guard, selection evidence only. Only SELECTION ops compete, total-ordered by compareRevisions. Message createdAt is deliberately not evidence — an imported archive, a straggler write or a skewed clock must not move the shared conversation. The retired workflow mode stamped a workflow-scoped active op on every thread open, so a genuine pre-upgrade snapshot restores the conversation the user was actually in.

5. Turn-output fencing. Ownership is pinned at user_message dispatch (pinTurnOwnerAtDispatch), not only at turn:working; onSay/onStream/onTodo return early and record() drops any non-user entry when the shown conversation is not the turn's owner. The straggler is dropped like an interrupt's, deliberately not re-routed into its owner thread (a fresh stamp there would yank the shared selection straight back).

6. The backend switch asks what the incoming backend HAS. A codex review found three P1s that shared one cause: the new per-backend conversation model was still coupled to the old provider-switch flow, which was built when switching always meant a fresh session. Fixed at two seams, not three point-fixes and not one (see Why two seams below):

  • The upgrade fork (lib/chat-history-store.js). A pre-upgrade snapshot holds one shared panel:global pointer and no per-backend keys, so every backend key fell back to the same thread id: Claude and Codex both claimed it, loadThread scrubbed its foreign session, and record() rewrote its provider on every append — two providers sharing and corrupting one transcript, on the first upgrade of every existing user. The legacy route is now forked by provider, the ownership stamp record() already writes. Both doors are closed: the legacy pointer and the no-pointer recency fallback, which was equally "the same id for every backend". A pointer a backend wrote for itself still outranks the stamp, so normal operation is untouched (a thread legitimately changes provider when the user switches backends while it is open). A provider-less thread fails closed; nothing is deleted and it still opens through the picker.
  • One handover decision (lib/backend-switch.js). planBackendHandover asks the store once what the incoming backend already has and derives both consequences, so they cannot drift apart again: (a) the outgoing thread keeps its sessionId — the tab pointer still goes — so switching back resumes instead of sending new_session, which is the per-backend persistence this PR exists to add and was being defeated by its own switch path; (b) the outgoing transcript is armed as one-shot context only when the incoming backend has no conversation. When it has one, loadThread resumes it and any armed context is cleared rather than merely skipped — anything armed earlier would otherwise ride the next user message into the wrong conversation.

Merge with main

This branch was ~440 commits behind. origin/main is merged in (now current through v0.14.41); 11 conflict hunks in comfyui-mcp-panel.js and 1 in workflow-chat-identity.spec.ts, resolved as:

  • Reconciled with main's lib/interactive-card-fence.js. Main independently built an interactive-card fence for the same problem class — its header records that it was found by the independent gate on this PR and deliberately scoped to leave fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884) #680's structural work alone. Both fences survive, because they are not interchangeable: fenceInteractiveCard() (main's classifier) guards ask_user/request_secret and weighs agentWorking + lastMintedThreadId as well, so an owner-less turn that minted its own thread is not mistaken for one painting into somebody else's conversation. This branch's turnOutputFenced() asks only "is the shown conversation the turn's owner", which is right for transcript output but would false-refuse that legitimate card — and a refusal costs the agent a tool error, so it must be the precise test.
  • pinTurnOwnerAtDispatch narrows main's fence's own documented residual: a turn whose turn:working is discarded by the stale-working guard no longer reaches the classifier with a null owner.
  • Main's bridgeOutage tracker (The mid-task nudge measures one backoff step, not the outage — a fast-returning restart loses its nudge #1145) supersedes this branch's lastBridgeDownAt; main's i18n tr() conversions kept throughout.
  • interactive-card-fence.test.mjs's "record() does not write the turn owner" assertion is matched as an assignment rather than a bare substring. Reading the owner inside record() is legitimate and now happens (the output fence consults it); the substring form also failed on the comment explaining that fence — the prose-predicate trap, which would have been "fixed" by renaming a comment while leaving the real rule unguarded. The accepted assignment forms mirror the write-enumeration above it, so a liveTurnThreadId ||= thread.id smuggled into record() still fails.
  • workflow-chat-identity.spec.ts: main's e2e: 2 specs fail — chat-history-v2 row count, workflow-chat-identity first record #847 save-then-assert-the-embed sequence is dropped. It passed only because the test forced the workflow scope this PR retires — the graph embed is written solely on the workflowStorageKey({ embed: true }) path, which panel scope never calls. Under the one shipping mode nothing is saved and nothing is embedded; provenance lives in history metadata and the graph is never touched.
  • Removed the i18n catalog entries orphaned by the retired setting (comfyui-mcp_chatScope in settings.json plus 8 panel.* keys in main.json, across all 12 locales) — caught by the i18n round-trip and settings-key gates.

Why two seams and not one

The review suggested collapsing all three P1s into a single decision in the switch path. Two of them do collapse — session preservation and replay disposal are both consequences of "what does the incoming backend have", and they are now one function so a future edit cannot fix them by halves.

The upgrade fork cannot join them: cold restore reaches it with no switch involved. A reload on a pre-upgrade snapshot resolves the pointer directly through selectPanelThread, so a fix living in runBackendSwitch would leave the corruption fully reachable by simply reloading the tab. It is a resolution rule and belongs in the store. Forcing it into the switch path would have been a worse design that also failed to fix the bug.

Tests

npm run test:unit4449 tests, 4448 pass, 0 fail, 1 todo. npm run typecheck clean. check-panel-scope passes (every name resolves — the gate that catches a name resolving in a sibling function, the live risk when merge resolution moves code between functions).

One test, #671 verifyInstalled … inside the reply window, is a wall-clock budget assertion that fails only when the box is loaded (it failed once during a run that took 104 s against a normal 40-70 s, and passes 122/122 in isolation). It covers ComfyUI-Manager install verification and is untouched by this branch.

Complementary gate suites pin the invariants:

  • browser_tests/unit/chat-history-store.test.mjs — source-level gates over the whole retired surface: chatScopeMode is a constant, no scope setting is registered, currentHistoryScopeKey resolves to a backend axis, every selection-pointer write uses the backend key, the workflow-keyed session bind is unreachable.
  • browser_tests/unit/chat-scope-retired.test.mjs — the behavioural half of the retirement: it extracts the shipped chatScopeMode and calls it against an injected settings store, for every value a retired scope could have persisted.
  • browser_tests/unit/turn-output-fence.test.mjs (new — closes the fence gap, see below).
  • browser_tests/unit/backend-switch.test.mjs — the handover: planBackendHandover as a pure decision, the effects it drives through runBackendSwitch, and the shipped invalidateDurableAgentSession body extracted and run.
  • browser_tests/unit/chat-history-store.test.mjs — the upgrade fork, including a test that two backends cannot resolve one legacy thread id, the provider-less fail-closed case, and the recency-fallback door.

Both behavioural suites are deliberately not source-regex tests. if (false) return getSetting(...) and if (false && turnOutputFenced()) match every regex anyone would write about those call sites while shipping the opposite behaviour, so only running them distinguishes the two.

Mutation results (committed first, then broken, then restored from an in-memory snapshot)

All twelve KILLED, re-run in full against the current head.

# Mutation Result
1 resolvePanelPointer — make the legacy fallback two-way (a cleared backend key falls back to panel:global again) KILLED — 3 tests
2 selectPanelThread — let another backend's selection op compete (drop the panel: skip) KILLED — 1 test
3 chatScopeMode() — restore the old "read the stored setting" body, un-retiring workflow/ask KILLED — 3 tests
4a record() — disable the turn-output fence via if (false && …) KILLED — 5 tests
4b onSay / onStream / onTodo — disable all three transcript fences via if (false && …) KILLED — 4 tests
5 record() — drop the USER-entry exemption (fence the user's own message too) KILLED — 1 test
6 pinTurnOwnerAtDispatch() — stop pinning at dispatch (ownership only at turn:working) KILLED — 10 tests
7 upgrade fork — un-fork the legacy route (every backend claims the same legacy thread again) KILLED — 4 tests
8 upgrade fork — the other door: un-fork the no-pointer recency fallback only KILLED — 3 tests
9 handover — ignore preserveThreadSession (destroy the outgoing backend's session again) KILLED — 2 tests
10 handover — always arm the replay (outgoing transcript rides into the incoming conversation) KILLED — 18 tests
11 handover — skip instead of clear (a context armed earlier still rides the next message) KILLED — 1 test

Mutations 3, 4 and 7 are the ones that mattered. 3 and 4 were this PR's headline behaviour with no CI coverage at all; 7 is the upgrade path every existing user takes.

Two of these survived their first run and are worth the reviewer's attention, because both are the failure mode where a fix looks tested and is not:

  • [2] regressed. It was killed before the P1 fixes and survived afterwards: the new upgrade fork masked it. Its fixture threads carried no provider, so after the fork another backend's op resolved to a thread this backend could not claim anyway, and deleting the panel: skip stopped failing anything. The two rules are different questions — "could this backend own it" vs "is another backend's selection evidence for mine" — and the guard is now pinned with a thread whose provider satisfies both, which is reachable precisely because a thread's provider changes when the user switches backends while it is open.
  • [9] was never covered. The handover tests proved runBackendSwitch passes preserveThreadSession; nothing proved the panel honours it. An invalidateDurableAgentSession that ignores the option destroys the outgoing session exactly as before and every assertion stayed green. The shipped body is now extracted and run, in both directions: a switch preserves the thread session, a restart still destroys it.

(Mutation 10 reports fewer total tests because it makes a whole test file throw at load; that is still a detection, just a louder one.)

The runner asserts an exact occurrence count for every anchor before applying it, and restores from an in-memory snapshot rather than git checkout. That guard earned its keep twice: mutation 1's first anchor matched 3 sites and was reported NOT APPLIED rather than as a kill.

How the turn-output fence gap (mutation 4) was closed

browser_tests/unit/turn-output-fence.test.mjs lifts the real turnOutputFenced, pinTurnOwnerAtDispatch, record, onSay, onStream and onTodo bodies out of the shipped panel source and executes them over stubbed collaborators — the established "real panel source" convention (interactive-card-fence.test.mjs's buildLifecycle(), context-ring-scope.test.mjs).

The earlier concern was that record() (124 lines) would need ~35 stubs and the harness would become a copy of the panel. It did not: the surface is 15 one-line stubs, every one either an observation point (persistThreads counts, appendAgent records what it was handed) or a fixture value (getWorkflowTitle, workflowTabId). The two collaborators that carry real logic — ChatHistoryStore (reviseThread/touchMessage) and isThreadInScope — are imported for real, so no thread-revision semantics are modelled in the test. Panel constants (MAX_THREADS, MAX_THREAD_MSGS, MAX_WORKFLOW_VERSIONS) are read out of the source rather than restated.

Extracting the fence predicate into web/js/lib/ was considered and rejected: turnOutputFenced() is already a correct two-line predicate, and testing it in isolation would not have killed this mutation. The mutation disables the call sites, not the predicate — if (false && correctPredicate()) passes any test written about correctPredicate. Only running the call sites separates them.

What the 14 new tests assert, all through the shipped bodies:

  • an abandoned turn's agent record is dropped — not filed under the adopted conversation, and not re-routed into its owner thread either;
  • a fenced record does not fall through to record()'s mint branch and create a conversation for output nobody asked for;
  • a user entry is exempt and still lands in the view the user typed into (and is the same object — record() mutates in place, never clones);
  • onSay / onStream / onTodo paint nothing when fenced;
  • both halves agree — the painting fence and the recording fence read one predicate, so output can never paint without being recorded or vice versa;
  • ownership really is armed at dispatch, before any turn:working frame arrives;
  • and every normal path is unchanged.

Each source anchor is asserted to occur exactly once before it is evaluated. This checkout is CRLF, and an LF-authored multi-line anchor that silently misses would inject an empty body and make every assertion vacuous — which reads exactly like a passing test. (That guard earned its keep: the first run of mutation 1's own anchor matched 3 sites and was correctly reported as NOT APPLIED rather than as a kill.)

Not verified: Playwright

The Playwright specs are not in CI (they need a live ComfyUI on :8188 with --workers=1) and were not re-run against this head. The adoption, backend-switch and abandoned-turn e2e claims should be treated as unverified. The unit gates above now cover the fence, the scope retirement, the upgrade fork and the handover in CI, but the multi-tab adoption choreography and the live backend-switch round trip are still only claimed, not demonstrated, on this head — and the backend-switch changes above are exactly the area a live round trip would exercise.

Known residual (unchanged, and it is the honest one)

The panel publishes the shared selection off sendFrame() returning true, which proves only that the local socket accepted bytes — not that the orchestrator applied the transition. Two connected actors racing can therefore still disagree. Closing that needs orchestrator-confirmed session transitions, which do not exist on the wire; it is mcp-side work, and main's interactive-card-fence.js independently reached and documented the same conclusion.

🤖 Generated with Claude Code

…low/ask chat scopes (mcp#884)

Companion to artokun/comfyui-mcp#884 (P0: agent sessions were workflow-scoped).
The owner-stated invariant: one agent session spans every workflow and every
tab, keyed and persisted by the orchestrator. The legacy 'workflow' and 'ask'
chat scopes were per-workflow sessions by another name, so chatScopeMode() is
hard-wired to 'panel' and the Settings combo is removed. Stored setting values
from older builds are ignored; per-workflow threads created under the old modes
remain in history and resume through the history picker like any archived
conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cross-tab history selection can corrupt session bindings, while existing tests and documentation still assume retired scopes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Retires legacy workflow/ask conversation scopes to enforce orchestrator-managed shared sessions.

Changes:

  • Hard-wires chat scope to panel-wide.
  • Removes the conversation-scope setting.
File summaries
File Description
web/js/comfyui-mcp-panel.js Enforces panel scope and removes its selector.
Review details

Suppressed comments (2)

web/js/comfyui-mcp-panel.js:2606

  • The browser suites still force the retired workflow mode and assert its behavior. For example, browser_tests/chat-history-v2.spec.ts:292-385 and workflow-chat-identity.spec.ts:203-270 expect foreign chats to be disabled, while conversation-persistence.spec.ts:502-583 expects a cross-scope sync to detach the session; all of those assertions become false with this unconditional return. Update or retire those cases and add coverage that stored workflow/ask values are ignored and workflow switches emit neither new_session nor resume_session.
    web/js/comfyui-mcp-panel.js:3045
  • Removing this setting leaves the checked-in design guide incorrect: docs/design/chat-history-v2.md:6-12 still directs users to “Chat conversation scope” and documents the retired Workflow and Ask options. Update that section so the public documentation matches the only supported behavior.
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread web/js/comfyui-mcp-panel.js Outdated
Comment on lines +2599 to +2600
* ALWAYS belongs to the panel. One agent session spans every workflow and every
* tab; the orchestrator keys and persists it (in ~/.comfyui-mcp/sessions), so a
artokun and others added 2 commits August 5, 2026 22:55
…on, not the tab (mcp#884 gate P0s)

The companion mcp#897 makes the agent session orchestrator-scoped: ONE
conversation per backend across every tab and workflow. The previous commit
hard-wired the panel scope, but the HISTORY layer still let each tab decide
which thread to render and record into. This commit makes thread identity
shared state resolved by one selector:

- selectPanelThread is now the single definition of "the conversation",
  with a stale-pointer guard: the panel:global pointer only pins a
  conversation over newer CONVERSATION activity (message recency, never
  rename/pin edits) when the pointer write itself is the most recent action.
  Fixes the cold-upgrade restore of a months-old chat (P0-2).
- selectRestoreThread: the shared pointer is authoritative on reload when it
  resolves (or was deliberately cleared); the tab-local pointer only bridges
  legacy snapshots and dangling pointers.
- The history-sync subscriber passively ADOPTS the shared selection: a tab
  whose selection moved repaints to the new thread and re-keys its
  sessionStorage, so its next user_message is recorded where the agent
  actually is (P0-1). Passive tabs never send session frames — only the tab
  the user acted in drives the orchestrator.
- Removed the last scope machinery the retired setting fed (applyChatScope,
  askModeFollowsPanel, the ask confirm) and the retired setting id consts.

Tests: unit coverage for the new selector rules (stale pointer, deliberate
open, metadata-edit immunity, shared-vs-tab pointer, dangling pointer);
rewrote the five workflow-scope Playwright cases (plus a sixth in
conversation-persistence) to the panel-mode contract, including live e2e
repros of both P0s; specs now route the whole web/js tree from the worktree
so they exercise this commit instead of the stale linked checkout.

Docs: chat-history-v2.md describes the single panel-owned conversation and
the removed setting.

Lands together with comfyui-mcp#897; neither is complete alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…versation (codex P0, round 2)

Passive adoption (and a same-tab history switch) can happen while a turn is
still streaming. The turn's owner is already pinned at turn:working
(liveTurnThreadId, #381); extend that ownership rule from usage frames to
transcript output: when the shown conversation is no longer the turn's owner,
say/stream/todo frames neither paint nor record, and record() drops any
non-user entry at its choke point. Dropped, not re-routed: stamping the
straggler into its owner thread now would hand it the newest conversation
activity and yank the shared selection straight back.

The adoption e2e now runs codex's exact scenario — turn in flight, another
tab moves the selection, a late say arrives — and asserts it lands nowhere;
mutation-checked (disabling the fence makes the spec fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
artokun and others added 3 commits August 5, 2026 23:52
…tion-evidence guard, full output fence, dispatch-time filing (gate round 3, five P0s)

P0-2 (structural, first): the shared selection pointer is now BACKEND-scoped
(panel:backend:<id>), mirroring the orchestrator's orchestrator::<backend>
session key — a Claude tab's selection can no longer move a Codex tab's
conversation or cost it its resumable session. The legacy panel:global key is
a one-way read fallback (resolvePanelPointer) until a backend's key is first
written.

P0-1: THE COMMIT IS THE TRANSITION. loadThread and newChat dispatch the
session frame FIRST and publish the shared selection only when the frame
actually left the socket — a disconnected tab still switches its own view but
cannot move every other tab onto a conversation the backend never entered.
(Cross-socket reorder between two connected actors still needs
orchestrator-confirmed transitions — mcp#897's side; noted in the PR body.)

P0-3: the stale-pointer guard now compares like with like — SELECTION
evidence only. The retired workflow mode stamped workflow-scoped active ops
on every thread creation/open; the newest selection op that resolves to a
live thread wins. Message timestamps are no longer evidence: an imported
archive, a straggler write, or a skewed clock cannot move the shared
conversation, and equal-revision ties are total-ordered by compareRevisions.

P0-4: turn ownership is pinned at user_message DISPATCH (pinTurnOwnerAtDispatch
at every send site), closing the pre-turn:working hole where an adoption's
endTurnLocally discarded the working frame inside the stale-working window
and left ownership null. The fence now covers EVERY user-visible output path:
say/stream/todo (round 2) plus ask_user, show_media, ui_render (honest tool
errors — an interactive card in the wrong conversation is not transient) and
command activity cards.

P0-5: the prompt's target conversation is decided at DISPATCH, not type time.
If the shared selection moves while sendCurrent awaits attachment uploads /
grounding / validation, the optimistically recorded prompt is relocated —
tombstoned out of the old thread and re-recorded + repainted in the current
one — before the frame goes out.

P2-6: the adoption e2e now drives the real actor seam (a second connected
panel clicking the history row → loadThread), plus two new specs: a
disconnected actor cannot move the shared conversation, and an abandoned
turn's say/todo/ask output cannot leak into a conversation opened mid-turn
(pre-working hole covered; content is seeded, selection is never written
directly).

Unit: selection-evidence + backend-scoping + one-way fallback matrix
(2390/2390 pass). Live Playwright: 14/15 + the new disconnected-actor spec
(the 1 failure is pre-existing, filed panel#694).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onnected delete queues its session reset (gate round 3 follow-ups)

Codex re-review of the round-3 commit found two flow holes the backend
scoping introduced:

1. Provider switching never adopted the target backend's conversation: the
handshake flipped the selection key while the previous provider's thread
stayed on screen, so the new backend's session ran against a conversation it
does not own while reloads and other tabs resolved its real one. The
handshake now compares the selection key before/after the backend resolves
and, when it changed, adopts the new backend's own conversation through the
normal actor path (loadThread → dispatch + publish under the NEW key; newChat
when it has none). Reconnects to the same backend are untouched (keys equal).
E2E: a second MockBridge reporting backend:codex; the panel adopts the seeded
codex conversation and claude's selection is left intact; mutation-checked.

2. Deleting the ACTIVE conversation while disconnected cleared and persisted
the shared pointer but silently lost the new_session frame — peers correctly
went blank (the transcript is deleted) but the backend kept the deleted
conversation's session. newChat now queues the reset it owes
(PENDING_SESSION_RESET_KEY, per backend scope); the next ready ack fires it
under three guards: same backend scope, the shared pointer is still in the
cleared state this tab left (any newer act supersedes and drops it), and the
frame actually sends. A delivered transition (loadThread/newChat dispatch)
clears the queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thread is already on screen (codex round-4 corner)

First connect can land on a different backend than the restored default while
the new backend's scope resolves the SAME legacy thread the panel already
shows. The target.id !== thread.id guard then skipped the transition entirely,
leaving the new backend's session unaligned (a foreign provider's session id
staged from restore, no replay). The adoption block now runs loadThread on
every backend-key change with a target — its provider check scrubs the
foreign session id, arms the transcript replay, and publishes under the new
backend's key. Same-backend reconnects remain untouched (keys equal).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
artokun added a commit that referenced this pull request Aug 7, 2026
… turn asked for them (#711)

* fix(panel): fence ask_user / request_secret to the conversation whose turn asked

Found by the independent gate on PR #680 and re-verified on origin/main.

The panel renders two INTERACTIVE cards on the agent's behalf that COLLECT a
value from the user and hand it back as the tool result: `request_secret` (a
masked token input) and `ask_user` (a question card). Both painted
unconditionally, while the handler sitting between them in the same object
(`onThinking`) is fenced on `agentWorking` precisely so a late frame from a
turn the user already ended cannot act on a screen it no longer owns.

For these two the consequence is not a stray indicator but a stray VALUE: an
abandoned or superseded turn could paint a secure input into whatever
conversation the tab happened to be showing, and the token typed there came
back as the result of a turn belonging to a DIFFERENT conversation. mcp #897
made agent sessions orchestrator-scoped, so "which conversation is on screen"
and "which turn this frame belongs to" are now genuinely separable.

The fence is the PAIR (agentWorking && liveTurnThreadId === thread?.id): a turn
must be in flight in this tab, AND the conversation captured as that turn's
owner at turn start must be the conversation on screen. Neither half suffices
alone -- see the header of web/js/lib/interactive-card-fence.js for why the
rid/epoch on the dispatch path and the frame's own fields cannot discriminate.

A refused card answers the agent with an explicit ok:false naming what was
refused, stating that nothing was shown, collected or stored, and giving the
one next step that works -- the tone command-liveness.js already uses for this
class. Nothing is painted anywhere else, and no value can be logged because the
refusal runs before any card exists.

Deliberately does NOT address PR #680's structural blocker (the panel publishes
shared conversation state off sendFrame() returning true, which needs
orchestrator-confirmed session transitions that do not exist yet).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(panel): answer my own gate — observation-only refusal wording, lifecycle tests, residuals documented

Round 1 of my independent codex gate raised three findings.

SEVERE (a straggler turn:working past onTurn's guard re-authorizes whatever
conversation is on screen) and IMPORTANT (the mirror: a genuinely fresh turn's
turn:working landing INSIDE that guard is discarded, so a legitimate card is
refused) share one root cause: the `turn` frame carries a state and no turn
identity. Closing either means putting a turn/conversation id on the wire, which
is a comfyui-mcp protocol change and out of scope for this panel fix. Both are
now documented in the module header AND pinned by a test each, so the residual is
visible and a future turn-id has a test to flip. The fix remains strictly better
than origin/main in both directions: the refused case fails CLOSED with an honest
error, and the SEVERE case needs a precondition main required nothing for.

The IMPORTANT finding also showed the refusal wording asserting a cause the panel
never observed ("has already ended"), which is exactly wrong in that case.
command-liveness.js's rule is 'reports what we OBSERVED, never a guess' — the
no_live_turn text now states the observation and offers causes as examples. The
next-step advice no longer implies a retry will help either.

MINOR (the handler tests injected idealized state, so they proved the predicate
rather than shipped lifecycle behaviour): added a section that wires the REAL
endTurnLocally and onTurn bodies to the REAL fence and handlers over one closure
with a fake clock, so agentWorking/liveTurnThreadId are produced by shipped code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(panel): let an owner-less turn own the conversation it MINTS, but only that one

Gate round 2 found a real false refusal I had argued away in the design.

A turn that begins on a view with no conversation captures liveTurnThreadId =
null. Its own first output then runs record(), which MINTS the conversation now
on screen. Refusing whenever owner !== shown therefore refused a card that
belongs to the visible turn, in the visible conversation.

The obvious inverse — paint whenever the owner is null — re-opens a hole in the
other direction: loadThread()'s cross-workflow BLOCKED branch calls
detachInvalidCurrentThread({rebind:true}) and RETURNS without endTurnLocally(),
so a thread-less live turn can find an OLD conversation on screen.

The discriminator is whether the shown conversation came into existence DURING
this turn. onTurn('working') now stamps liveTurnStartedAt (cleared on done) and
the fence compares it against thread.createdAt, which record()'s mint stamps off
the same Date.now(). Missing or unusable timestamps fail CLOSED. A source-level
test pins both stamps to the same clock and pins that record() does NOT
retroactively adopt the thread as the turn owner, so the comparison cannot
quietly become dead code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(panel): decide the owner-less case by PROVENANCE, not by age

Gate round 3 broke round 2's discriminator, correctly. Comparing the shown
conversation's createdAt against the turn start proves it is NEWER than the turn,
not that this turn created it: a conversation minted in ANOTHER TAB after the turn
began can sync into this tab's history and be rebound onto the screen by
detachInvalidCurrentThread(), and it would have passed.

Replaced with the fact itself. record()'s mint branch — the only place a
conversation is ever created — records the id in a module-level
`lastMintedThreadId`, and onTurn('working') resets it, so a non-null value means
exactly 'record() created this conversation during the turn now running'. The
owner-less branch paints only against that exact id. A conversation that merely
APPEARED can never satisfy it, whatever its age. liveTurnStartedAt and the
timestamp comparison are gone.

The marker is module-scoped deliberately: record() runs from many points inside
the panel builder closure, and a `let` declared partway down that closure would be
in its temporal dead zone for any call reaching record() earlier. Only one panel
is mounted at a time.

Also from round 3:
 - MINOR: the 'liveTurnStartedAt is 0 between turns' comment was untrue because
   endTurnLocally() left it stale — moot, the variable no longer exists.
 - MINOR: the source pins were loose enough to miss a plausible refactor. The mint
   pin is now brace-bounded to record()'s own thread-creation branch (verified: an
   assignment moved to a sibling branch now fails it, where before it passed), the
   record() slice is sanity-checked to actually span record(), and every
   assignment to lastMintedThreadId in the file is enumerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(panel): harden the provenance pins against assignment forms and reset hoisting

Narrow re-gate of the previous commit found the marker itself sound (no forgery
path, remount-safe, nothing dangling) but the SOURCE PINS loose in two ways:

 - the writer enumeration only matched a literal `= `, so a later
   `lastMintedThreadId ||= replacement.id` in a rebind path would have slipped
   through while letting a conversation nobody minted vouch for itself;
 - the reset pin accepted a reset hoisted above onTurn's 'working' branch, which
   would fire on 'done' too — a weaker meaning than 'since this turn began'.

Both now fail loudly (mutation-verified: each hypothetical regression applied to
the panel source fails this test).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
artokun and others added 2 commits August 14, 2026 17:29
433 commits of main, two conflicted files, eleven hunks. The branch is 6 commits
old and main moved the whole i18n layer under it, so most hunks were "main
translated the thing this branch deletes".

Resolutions, and why:

* PENDING_SESSION_RESET_KEY vs bridgeOutage (#1145) — kept BOTH. Main replaced
  `lastBridgeDownAt` with a tracker; that variable had no readers left, so
  dropping it was the correct half of the resolution, not a loss.
* chatScopeMode / applyPanelLocale — kept main's new locale loader AND the
  branch's ownership comment. The function body did not actually conflict: main
  never touched it, so the branch's `return "panel"` applied cleanly.
* "Chat conversation scope" combo, the `ask` window.confirm, and
  panelHooks.applyChatScope — branch wins, all three deleted. Main had only
  wrapped their strings in tr(). Deleting them IS this PR.
* onAsk — main wins outright. Both sides had added an ownership fence; main's
  `fenceInteractiveCard()` decides the same question with strictly more evidence
  (it also weighs `agentWorking` and `lastMintedThreadId`), and it carries the
  `socketId` parameter main's `paintQuestion` now needs. The branch's plainer
  `turnOutputFenced()` refusal here would have been a worse duplicate.
* Backend-switch handshake — kept both: the branch's `previousScopeKey` capture
  (before `connectedBackend` moves, which is what the key derives from) and
  main's "sessions aren't shared across providers" notice.
* record()'s mint — kept the branch's backend-scoped `setActiveThread` AND
  main's `lastMintedThreadId` write.
* workflow-chat-identity.spec.ts — branch wins. Main's #847 block saved a
  workflow to make an embed assertion reachable, and ran under
  `setWorkflowScope(page)` forcing `chatScope: 'workflow'`. Neither exists here:
  no scope setting, so `workflowStorageKey({ embed: true })` is never reached and
  there is no graph tag to assert. Main's cleanup block went with it — it deleted
  the file that is no longer created (`savedAs` would have been undefined; caught
  by tsc, which test:unit does not cover).

Fallout the merge exposed, fixed here:

* Retiring the setting orphaned its i18n rows. Removed `comfyui-mcp_chatScope`
  from all 12 `locales/*/settings.json` and the 8 now-dead scope strings from all
  12 `locales/*/main.json` — the settings-i18n-keys and i18n catalog gates both
  failed on them, which is the gates working.
* interactive-card-fence's "record() does not write the turn owner" matched a
  bare SUBSTRING, so it failed on the branch's COMMENT explaining the output
  fence. Narrowed to an ASSIGNMENT check, using the same assignment forms the
  test's own write-enumeration already accepts. Mutation-checked: adding
  `liveTurnThreadId = thread.id;` to record() still fails it.

Verified: `npm run test:unit` 4406/4407 (1 todo, 0 fail) with the i18n,
tool-vocabulary and panel-scope gates chained; `tsc --noEmit` clean.

Refs artokun/comfyui-mcp#884, artokun/comfyui-mcp#897

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Sessions are ORCHESTRATOR-scoped, never workflow-scoped or tab-scoped" is a
project invariant, and this branch is what makes the panel honour it. Until now
that was only checkable by reading: `chatScopeMode()` is a constant, so every
workflow-scoped path is unreachable — but nothing FAILED if it stopped being a
constant, and the retired machinery is all still present as deliberate
defence-in-depth. That combination is one edit away from waking up.

Five assertions, each mutation-checked against the edit it is supposed to catch:

1. `chatScopeMode()` returns the literal "panel" and reads no stored value.
   (Mutation: make it read SETTING_CHAT_SCOPE again → fails.)
2. No chat-scope row is registered in panelSettingsList(), and no live
   `applyChatScope` hook survives outside comments.
3. `currentHistoryScopeKey()`'s panel answer is keyed `panel:backend:<id>` — the
   same axis as the orchestrator's `orchestrator::<backend>` — and the workflow
   key stays reachable ONLY behind the `historyScopeFollowsPanel()` guard.
   (Mutation: revert the key to the shared "panel:global" → fails.)
4. EVERY `setActiveThread` write addresses the backend key, never a workflow key.
   The pointer decides which conversation a tab renders and records into, so a
   single workflow-keyed writer would make the conversation workflow-scoped again
   whatever `chatScopeMode()` says. (Mutation: one writer switched to
   `workflowStorageKey()` → fails.)
5. `onWorkflowMaybeChanged`'s panel-owned branch RETURNS, which is the only thing
   keeping the workflow-keyed `ssSet(SESSION_KEY, existing?.sessionId)` tail dead.
   (Mutation: delete that `return;` → fails.)

Assertion 5 is the one worth keeping. That line is where a session used to belong
to a WORKFLOW; it still exists, and its deadness rests entirely on a `return`
several hundred lines above it. Nothing else in the suite noticed that.

Refs artokun/comfyui-mcp#884

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@artokun

artokun commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Merged main (433 commits), suite green, and the invariant is now a gate

Head is 0c337ab6. mergeable went CONFLICTING → MERGEABLE; still a draft, not merged.

The invariant: is workflow-scoping REMOVED, or relocated?

Removed from every reachable path. The conversation is now keyed on the backend, which is the same axis the orchestrator keys its session on (orchestrator::<backend>, mcp#897) — not on a workflow, and not on a tab:

function currentHistoryScopeKey({ embed = false } = {}) {
  if (!historyScopeFollowsPanel()) return workflowStorageKey({ embed });   // unreachable, see below
  return `panel:backend:${connectedBackend || selectedBackend || "claude"}`;
}

Evidence, checked rather than assumed:

  • chatScopeMode() has exactly one return, the literal "panel", and reads no stored value. historyScopeFollowsPanel() is therefore constant-true.
  • Every setActiveThread(...) writer — the pointer that decides which conversation a tab renders and records into — passes currentHistoryScopeKey(), a local assigned from it, or a key iterated out of existing metadata during the delete sweep. No writer can address that pointer by a workflow key.
  • The retired symbols (SETTING_CHAT_SCOPE, SETTING_SESSION_FOLLOWS_PANEL, applyChatScope, askModeFollowsPanel) survive only inside comments.
  • tab_id stays routing-only: workflowRouteKey: workflowTabId() is ride-along provenance on a thread for archive grouping, never a selection key.

What is NOT removed, and a maintainer should decide about deliberately. The workflow-scoped machinery is all still present, behind !historyScopeFollowsPanel() / !panelOwned: selectThreadForScope, threadForWorkflow, the detachInvalidCurrentThread({rebind:true}) calls, and — the one that matters — this, in onWorkflowMaybeChanged:

const existing = threadForWorkflow(historyKey);
ssSet(SESSION_KEY, existing?.sessionId || null);   // a session belonging to a WORKFLOW

That is the exact line mcp#884 is about. It is dead today for one reason only: the if (followsPanel) { … return; } branch several hundred lines above it. The author kept these as defence-in-depth seams, which is defensible — but "unreachable because a constant says so, with nothing checking that it stays constant" is one edit away from waking the whole thing up, and nothing in the suite noticed.

So I pinned it. New commit 0c337ab6 adds five assertions, each mutation-checked against the edit it exists to catch:

assertion mutation that must kill it result
chatScopeMode() is a literal constant make it read SETTING_CHAT_SCOPE again fails ✓
no chat-scope row/hook is registered — (static)
the pointer is keyed panel:backend:<id> revert to shared panel:global fails ✓
every pointer write uses the backend key one writer → workflowStorageKey() fails ✓
the panel-owned branch RETURNS delete that return; fails ✓

Merge resolutions worth a second pair of eyes

Eleven hunks; most were "main translated the thing this branch deletes", where the branch wins. Three were judgement calls:

  1. onAsk — I took main's side outright. Both sides had independently added an ownership fence. Main's fenceInteractiveCard() answers the same question with strictly more evidence (it also weighs agentWorking and lastMintedThreadId, so an owner-less turn that minted its own thread is not mistaken for one painting into someone else's conversation), and it carries the socketId argument main's paintQuestion now needs. Commit 3's plainer turnOutputFenced() refusal here would have been a worse duplicate. The branch's P0-4 ask_user fence is therefore superseded, not lost — worth knowing when re-reading that commit's rationale.
  2. workflow-chat-identity.spec.ts — branch wins. Main's e2e: 2 specs fail — chat-history-v2 row count, workflow-chat-identity first record #847 block saved a workflow so an embed assertion could be reached, and ran under setWorkflowScope(page) forcing chatScope: 'workflow'. Neither survives: with no scope setting, workflowStorageKey({ embed: true }) is never reached and there is no graph tag to assert. Main's cleanup block went with it — otherwise savedAs was undefined. tsc caught that; note test:unit does not typecheck the specs, so I ran tsc --noEmit separately (clean).
  3. interactive-card-fence's "record() does not write the turn owner" matched a bare substring and so failed on the branch's comment explaining the output fence. I narrowed it to an assignment check, reusing the assignment forms that test's own write-enumeration already accepts. Mutation-checked: putting liveTurnThreadId = thread.id; into record() still fails it. Flagging it because I edited a guard that came from main.

Merge fallout, fixed

Retiring the setting orphaned its translations, and two gates caught it: comfyui-mcp_chatScope removed from all 12 locales/*/settings.json, and the 8 dead scope strings from all 12 locales/*/main.json.

Tests

npm run test:unit4406 pass, 0 fail, 1 todo (4407), with the i18n, tool-vocabulary and panel-scope gates chained. tsc --noEmit clean. Playwright is unchanged from the PR body and still not in CI.

For the maintainer

artokun and others added 9 commits August 14, 2026 17:33
…scoped-sessions

# Conflicts:
#	browser_tests/workflow-chat-identity.spec.ts
#	web/js/comfyui-mcp-panel.js
The retirement was covered only by Playwright specs, which are not in CI (they
need a live ComfyUI on :8188). A mutation proved the gap: restoring
chatScopeMode()'s old "read the stored setting" body left the whole unit suite
green at 4401/4401, so nothing in CI could tell that the retired workflow/ask
scopes had come back — and a user left in one of them gets several panel
transcripts mapping onto the single session the orchestrator actually runs.

The load-bearing cases EXTRACT the shipped function and CALL it against a
stubbed settings store, per the repo's "real panel source" convention. A source
regex would not do: `if (false) return getSetting(...)` matches any pattern
written about the old body while still shipping correct behaviour, and the
mutation that matters must fail. Both new cases fail against that mutation.

The two structural cases (no registered scope row, no live applyChatScope /
askModeFollowsPanel) are defense in depth for the other direction — a re-added
combo would be a visible control that silently does nothing, and the obvious
"fix" for that is to wire it back up. They deliberately do NOT fail on the
chatScopeMode mutation, which is precision, not weakness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…okun/comfyui-mcp-panel into fix/884-orchestrator-scoped-sessions

# Conflicts:
#	browser_tests/workflow-chat-identity.spec.ts
#	web/js/comfyui-mcp-panel.js
The independent mutation gate disabled the fence at every call site --
`if (false && turnOutputFenced())` in record(), onSay, onStream and onTodo --
and the whole unit suite stayed green. The fence was correct and completely
unpinned: its only coverage was a Playwright spec that is not in CI.

A source-regex test cannot close this. `if (false && turnOutputFenced())`
still contains the string every regex would match, so only RUNNING the call
sites separates the live fence from a disabled one.

So this drives the shipped bodies. turnOutputFenced, pinTurnOwnerAtDispatch,
record, onSay, onStream and onTodo are lifted straight out of the panel source
and executed over stubbed collaborators -- the established "real panel source"
convention (interactive-card-fence.test.mjs's buildLifecycle,
context-ring-scope.test.mjs).

The stub surface stays observational rather than a reimplementation: 15
one-line stubs that count calls or return fixture values, plus the REAL
ChatHistoryStore and the REAL isThreadInScope, so no thread-revision semantics
are modelled here. Every anchor is asserted to occur exactly once -- this
checkout is CRLF, and an LF-authored multi-line anchor that silently misses
reads exactly like a passing test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions' into fix/884-orchestrator-scoped-sessions
Three P1s from the codex review of ef5de74, and they share one cause: the
per-backend conversation model was still coupled to the OLD provider-switch
flow, which was built when switching ALWAYS meant a fresh session.

Fixed at TWO seams rather than three point-fixes or one:

1. lib/chat-history-store.js -- the UPGRADE FORK. A pre-upgrade snapshot holds
   one shared `panel:global` pointer and no per-backend keys, so every backend
   key fell back to the SAME thread id: Claude and Codex both claimed it,
   loadThread scrubbed its foreign session and record() rewrote its provider on
   every append. Two providers shared and corrupted one transcript, on the first
   upgrade of every existing user.

   The legacy route is now forked by `provider`, the ownership stamp record()
   already writes. Both doors are closed -- the legacy pointer AND the
   no-pointer recency fallback, which was equally "the same id for every
   backend". A pointer a backend wrote for ITSELF still wins over the stamp, so
   normal operation is untouched. A provider-less thread fails closed; nothing
   is deleted and it still opens through the picker.

   This cannot live in the switch path: cold restore reaches it with no switch
   involved.

2. lib/backend-switch.js -- ONE handover decision. `planBackendHandover` asks
   the store once what the incoming backend already has, and derives both
   consequences, so they cannot drift apart again:

   - the outgoing THREAD keeps its sessionId (the tab pointer still goes), so
     switching back resumes instead of sending new_session -- the per-backend
     persistence this branch exists to add was being defeated by its own switch
     path;
   - the outgoing transcript is armed as one-shot context ONLY when the incoming
     backend has no conversation. When it HAS one, loadThread resumes it and the
     armed context is CLEARED rather than merely skipped -- anything armed
     earlier would otherwise ride the next message into the wrong conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mutation 2 REGRESSED and 9 was never covered:

[2] The upgrade fork MASKED the `panel:` skip. Its fixture threads had no
    provider, so after the fork another backend`s op resolved to a thread this
    backend could not claim anyway and deleting the skip stopped failing
    anything. The two rules are different questions -- "could this backend own
    it" vs "is another backend`s selection evidence for mine" -- so the guard is
    now pinned with a thread whose provider matches BOTH, which is reachable
    because a thread`s provider changes when the user switches backends while it
    is open.

[9] The handover tests proved runBackendSwitch PASSES preserveThreadSession, not
    that the panel HONOURS it. An invalidateDurableAgentSession that ignores the
    option destroys the outgoing session exactly as before and every assertion
    stayed green. The shipped body is now extracted and RUN, both directions:
    a switch preserves the thread session, a restart still destroys it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@artokun
artokun marked this pull request as ready for review August 15, 2026 05:03
@artokun
artokun merged commit c9226d6 into main Aug 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants